<!DOCTYPE html>
<html>
    
<head>
    <meta charset="utf-8">
    
    <title>手把手教你写Socket长连接 | ApocalypseBlog</title>
    <canvas id="header_canvas"style="position:absolute;bottom:0"></canvas> 
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">

    

    

    

    

    
<link rel="stylesheet" href="/dist/build.css?v=1.14.0.css">

    <script src="/javascripts/bubble.js"></script>
    <script>
        window.isPost = true
        window.aomori = {
            
            
        }
        window.aomori_logo_typed_animated = false
        window.aomori_search_algolia = false

    </script>
<script>
    ((window.gitter = {}).chat = {}).options = {
      room: 'ApocalypseBlog/Apocalypse'
    };
  </script>
  <script src="https://sidecar.gitter.im/dist/sidecar.v1.js" async defer></script>
<meta name="generator" content="Hexo 5.3.0"></head>
<!--DaoVoice服务http://blog.daovoice.io/-->

    <script>(function (i, s, o, g, r, a, m) {
        i['DaoVoiceObject'] = r;
        i[r] = i[r] ||
          function () {
            (i[r].q = i[r].q || []).push(arguments);
          };
        i[r].l = 1 * new Date();
        a = s.createElement(o);
        m = s.getElementsByTagName(o)[0];
        a.async = 1;
        a.src = g;
        a.charset = 'utf-8';
        m.parentNode.insertBefore(a, m);
      })(window, document, 'script', ('https:' === document.location.protocol ? 'https:' : 'http:') + "//widget.daovoice.io/widget/b00f5052.js", 'daovoice');
      daovoice('init', {
        app_id: "b00f5052",
      });
      daovoice('update');
    </script>
  
<body>

    <div class="container">
    <header class="header">
        <div class="header-type">
            
            <div class="header-type-inner">
                
                    <a class="header-type-title" href="/">ApocalypseBlog</a>
                
    
                
            </div>
        </div>
        <div class="header-menu">
            <div class="header-menu-inner">
                
            </div>
            <div class="header-menu-social">
                
            </div>
        </div>

        <div class="header-menu-mobile">
            <div class="header-menu-mobile-inner" id="mobile-menu-open">
                <i class="icon icon-menu"></i>
            </div>
        </div>
    </header>

    <div class="header-menu-mobile-menu">
        <div class="header-menu-mobile-menu-bg"></div>
        <div class="header-menu-mobile-menu-wrap">
            <div class="header-menu-mobile-menu-inner">
                <div class="header-menu-mobile-menu-close" id="mobile-menu-close">
                    <i class="icon icon-cross"></i>
                </div>
                <div class="header-menu-mobile-menu-list">
                    
                </div>
            </div>
        </div>
    </div>

</div>

    <div class="container">
        <div class="main">
            <section class="inner">
                <section class="inner-main">
                    <div class="post">
    <article id="post-ckk6o6aw100147kny96dze6z7" class="article article-type-post" itemscope
    itemprop="blogPost">

    <div class="article-inner">

        
          
        
        
        

        
        <header class="article-header">
            
  
    <h1 class="article-title" itemprop="name">
      手把手教你写Socket长连接
    </h1>
  

        </header>
        

        <div class="article-more-info article-more-info-post hairline">

            <div class="article-date">
  <time datetime="2020-06-11T21:55:57.000Z" itemprop="datePublished">2020-06-12</time>
</div>

            

            

            

        </div>

        <div class="article-entry post-inner-html hairline" itemprop="articleBody">
            <p>本篇我们先简单了解一下 TCP/IP，然后通过实现一个 echo 服务器来学习 Java 的 Socket API。最后我们聊聊偏高级一点点的 socket 长连接和协议设计。</p>
<a id="more"></a>

<h2 id="TCP-IP-协议简介"><a href="#TCP-IP-协议简介" class="headerlink" title="TCP/IP 协议简介"></a>TCP/IP 协议简介</h2><h4 id="IP"><a href="#IP" class="headerlink" title="IP"></a>IP</h4><p>首先我们看 IP（Internet Protocol）协议。IP 协议提供了<strong>主机和主机</strong>间的通信。</p>
<p>为了完成不同主机的通信，我们需要某种方式来唯一标识一台主机，这个标识，就是著名的<strong>IP地址</strong>。通过IP地址，IP 协议就能够帮我们把一个数据包发送给对方。</p>
<h4 id="TCP"><a href="#TCP" class="headerlink" title="TCP"></a>TCP</h4><p>前面我们说过，IP 协议提供了主机和主机间的通信。TCP 协议在 IP 协议提供的主机间通信功能的基础上，完成这两个主机上<strong>进程对进程</strong>的通信。</p>
<p>有了 IP，不同主机就能够交换数据。但是，计算机收到数据后，并不知道这个数据属于哪个进程（简单讲，进程就是一个正在运行的应用程序）。TCP 的作用就在于，让我们能够知道这个数据属于哪个进程，从而完成进程间的通信。</p>
<p>为了标识数据属于哪个进程，我们给需要进行 TCP 通信的进程分配一个唯一的数字来标识它。这个数字，就是我们常说的<strong>端口号</strong>。</p>
<p>TCP 的全称是 Transmission Control Protocol，大家对它说得最多的，大概就是<strong>面向连接</strong>的特性了。之所以说它是有连接的，是说在进行通信前，通信双方需要先经过一个<em>三次握手</em>的过程。三次握手完成后，连接便建立了。这时候我们才可以开始发送/接收数据。（与之相对的是 UDP，不需要经过握手，就可以直接发送数据）。</p>
<p>下面我们简单了解一下三次握手的过程。</p>
<p><img src="http://user-gold-cdn.xitu.io/2018/6/29/1644c0f951971fd5?imageView2/0/w/1280/h/960/format/webp/ignore-error/1" alt="tcp-three-way-handshake"></p>
<ol>
<li>首先，客户向服务端发送一个 <code>SYN</code>，假设此时 sequence number 为 <code>x</code>。这个 <code>x</code> 是由操作系统根据一定的规则生成的，不妨认为它是一个随机数。</li>
<li>服务端收到 <code>SYN</code> 后，会向客户端再发送一个 <code>SYN</code>，此时服务器的 <code>seq number = y</code>。与此同时，会 <code>ACK x+1</code>，告诉客户端“已经收到了 <code>SYN</code>，可以发送数据了”。</li>
<li>客户端收到服务器的 <code>SYN</code> 后，回复一个 <code>ACK y+1</code>，这个 <code>ACK</code> 则是告诉服务器，<code>SYN</code> 已经收到，服务器可以发送数据了。</li>
</ol>
<p>经过这 3 步，TCP 连接就建立了。这里需要注意的有三点：</p>
<ol>
<li>连接是由客户端主动发起的</li>
<li>在第 3 步客户端向服务器回复 <code>ACK</code> 的时候，TCP 协议是允许我们携带数据的。之所以做不到，是 API 的限制导致的。</li>
<li>TCP 协议还允许 “四次握手” 的发生，同样的，由于 API 的限制，这个极端的情况并不会发生。</li>
</ol>
<p>TCP/IP 相关的理论知识我们就先了解到这里。关于 TCP，还有诸如可靠性、流量控制、拥塞控制等非常有趣的特性，<strong>强烈推荐</strong>读者看一看 Richard 的名著《TCP/IP 详解 - 卷1》（注意，是<strong>第1版</strong>，不是第2版）。</p>
<p>下面我们看一些偏实战的东西。</p>
<h2 id="Socket-基本用法"><a href="#Socket-基本用法" class="headerlink" title="Socket 基本用法"></a>Socket 基本用法</h2><p>Socket 是 TCP 层的封装，通过 socket，我们就能进行 TCP 通信。</p>
<p>在 Java 的 SDK 中，socket 的共有两个接口：用于监听客户连接的 <code>ServerSocket</code> 和用于通信的 <code>Socket</code>。使用 socket 的步骤如下：</p>
<ol>
<li>创建 <code>ServerSocket</code> 并监听客户连接</li>
<li>使用 <code>Socket</code> 连接服务端</li>
<li>通过 <code>Socket</code> 获取输入输出流进行通信</li>
</ol>
<p>下面，我们通过实现一个简单的 echo 服务来学习 socket 的使用。所谓的 echo 服务，就是客户端向服务端写入任意数据，服务器都将数据原封不动地写回给客户端。</p>
<p><strong>1. 创建 ServerSocket 并监听客户连接</strong></p>
<pre><code>public class EchoServer &#123;

    private final ServerSocket mServerSocket;

    public EchoServer(int port) throws IOException &#123;
        // 1. 创建一个 ServerSocket 并监听端口 port
        mServerSocket = new ServerSocket(port);
    &#125;

    public void run() throws IOException &#123;
        // 2. 开始接受客户连接
        Socket client = mServerSocket.accept();
        handleClient(client);
    &#125;

    private void handleClient(Socket socket) &#123;
        // 3. 使用 socket 进行通信 ...
    &#125;


    public static void main(String[] argv) &#123;
        try &#123;
            EchoServer server = new EchoServer(9877);
            server.run();
        &#125; catch (IOException e) &#123;
            e.printStackTrace();
        &#125;
    &#125;
&#125;
</code></pre>
<p><strong>2. 使用 Socket 连接服务端</strong></p>
<pre><code>public class EchoClient &#123;

    private final Socket mSocket;

    public EchoClient(String host, int port) throws IOException &#123;
        // 创建 socket 并连接服务器
        mSocket = new Socket(host, port);
    &#125;

    public void run() &#123;
        // 和服务端进行通信
    &#125;


    public static void main(String[] argv) &#123;
        try &#123;
            // 由于服务端运行在同一主机，这里我们使用 localhost
            EchoClient client = new EchoClient(&quot;localhost&quot;, 9877);
            client.run();
        &#125; catch (IOException e) &#123;
            e.printStackTrace();
        &#125;
    &#125;
&#125;
</code></pre>
<p><strong>3. 通过 socket.getInputStream()/getOutputStream() 获取输入/输出流进行通信</strong></p>
<p>首先，我们来实现服务端：</p>
<pre><code>public class EchoServer &#123;
    // ...

    private void handleClient(Socket socket) throws IOException &#123;
        InputStream in = socket.getInputStream();
        OutputStream out = socket.getOutputStream();
        byte[] buffer = new byte[1024];
        int n;
        while ((n = in.read(buffer)) &gt; 0) &#123;
            out.write(buffer, 0, n);
        &#125;
    &#125;
&#125;
</code></pre>
<p>可以看到，服务端的实现其实很简单，我们不停地读取输入数据，然后写回给客户端。</p>
<p>下面我们看看客户端。</p>
<pre><code>public class EchoClient &#123;
    // ...

    public void run() throws IOException &#123;
        Thread readerThread = new Thread(this::readResponse);
        readerThread.start();

        OutputStream out = mSocket.getOutputStream();
        byte[] buffer = new byte[1024];
        int n;
        while ((n = System.in.read(buffer)) &gt; 0) &#123;
            out.write(buffer, 0, n);
        &#125;
    &#125;

    private void readResponse() &#123;
        try &#123;
            InputStream in = mSocket.getInputStream();
            byte[] buffer = new byte[1024];
            int n;
            while ((n = in.read(buffer)) &gt; 0) &#123;
                System.out.write(buffer, 0, n);
            &#125;
        &#125; catch (IOException e) &#123;
            e.printStackTrace();
        &#125;
    &#125;
&#125;
</code></pre>
<p>客户端会稍微复杂一点点，在读取用户输入的同时，我们又想读取服务器的响应。所以，这里创建了一个线程来读服务器的响应。</p>
<p>不熟悉 lambda 的读者，可以把 <code>Thread readerThread = new Thread(this::readResponse)</code> 换成下面这个代码：</p>
<pre><code>Thread readerThread = new Thread(new Runnable() &#123;
    @Override
    public void run() &#123;
        readResponse();
    &#125;
&#125;);
</code></pre>
<p>打开两个 terminal 分别执行如下命令：</p>
<pre><code>$ javac EchoServer.java
$ java EchoServer

$ javac EchoClient.java
$ java EchoClient
hello Server
hello Server
foo
foo
</code></pre>
<p>在客户端，我们会看到，输入的所有字符都打印了出来。</p>
<p>最后需要注意的有几点：</p>
<ol>
<li>在上面的代码中，我们所有的异常都没有处理。实际应用中，在发生异常时，需要关闭 socket，并根据实际业务做一些错误处理工作</li>
<li>在客户端，我们没有停止 <code>readThread</code>。实际应用中，我们可以通过关闭 socket 来让线程从阻塞读中返回。推荐读者阅读《Java并发编程实战》</li>
<li>我们的服务端只处理了一个客户连接。如果需要同时处理多个客户端，可以创建线程来处理请求。这个作为练习留给读者来完全。</li>
</ol>
<h2 id="Socket、ServerSocket-傻傻分不清楚"><a href="#Socket、ServerSocket-傻傻分不清楚" class="headerlink" title="Socket、ServerSocket 傻傻分不清楚"></a>Socket、ServerSocket 傻傻分不清楚</h2><p>在进入这一节的主题前，读者不妨先考虑一个问题：在上一节的实例中，我们运行 echo 服务后，在客户端连接成功时，一个有多少个 socket 存在？</p>
<p>答案是 3 个 socket。客户端一个，服务端有两个。跟这个问题的答案直接关联的是本节的主题——<code>Socket</code> 和 <code>ServerSocket</code> 的区别是什么。</p>
<p>眼尖的读者，可能会注意到在上一节我是这样描述他们的：</p>
<blockquote>
<p>在 Java 的 SDK 中，socket 的共有两个接口：用于监听客户连接的 <code>ServerSocket</code> 和用于通信的 <code>Socket</code>。</p>
</blockquote>
<p>注意，我只说 <code>ServerSocket</code> 是用于监听客户连接，而没有说它也可以用来通信。下面我们来详细了解一下他们的区别。</p>
<blockquote>
<p>注：以下描述使用的是 UNIX/Linux 系统的 API</p>
</blockquote>
<p>首先，我们创建 <code>ServerSocket</code> 后，内核会创建一个 socket。这个 socket 既可以拿来监听客户连接，也可以连接远端的服务。由于 <code>ServerSocket</code> 是用来监听客户连接的，紧接着它就会对内核创建的这个 socket 调用 <code>listen</code> 函数。这样一来，这个 socket 就成了所谓的 listening socket，它开始监听客户的连接。</p>
<p>接下来，我们的客户端创建一个 <code>Socket</code>，同样的，内核也创建一个 socket 实例。内核创建的这个 socket 跟 <code>ServerSocket</code> 一开始创建的那个没有什么区别。不同的是，接下来 <code>Socket</code> 会对它执行 <code>connect</code>，发起对服务端的连接。前面我们说过，socket API 其实是 TCP 层的封装，所以 <code>connect</code> 后，内核会发送一个 <code>SYN</code> 给服务端。</p>
<p>现在，我们切换角色到服务端。<strong>服务端的主机在收到这个 <code>SYN</code> 后，会创建一个新的 socket</strong>，这个新创建的 socket 跟客户端继续执行三次握手过程。</p>
<p>三次握手完成后，我们执行的 <code>serverSocket.accept()</code> 会返回一个 <code>Socket</code> 实例，这个 socket 就是上一步内核自动帮我们创建的。</p>
<p>所以说，在一个客户端连接的情况下，其实有 3 个 socket。</p>
<p>关于内核自动创建的这个 socket，还有一个很有意思的地方。它的端口号跟 <code>ServerSocket</code> 是一毛一样的。咦！！不是说，一个端口只能绑定一个 socket 吗？其实这个说法并不够准确。</p>
<p>前面我说的TCP 通过端口号来区分数据属于哪个进程的说法，在 socket 的实现里需要改一改。Socket 并不仅仅使用端口号来区别不同的 socket 实例，而是使用 <code>&lt;peer addr:peer port, local addr:local port&gt;</code> 这个四元组。</p>
<p>在上面的例子中，我们的 <code>ServerSocket</code> 长这样：<code>&lt;*:*, *:9877&gt;</code>。意思是，可以接受任何的客户端，和本地任何 IP。</p>
<p><code>accept</code> 返回的 <code>Socket</code> 则是这样： <code>&lt;127.0.0.1:xxxx, 127.0.0.1:9877&gt;</code>，其中<code>xxxx</code> 是客户端的端口号。</p>
<p>如果数据是发送给一个已连接的 socket，内核会找到一个完全匹配的实例，所以数据准确发送给了对端。</p>
<p>如果是客户端要发起连接，这时候只有 <code>&lt;*:*, *:9877&gt;</code> 会匹配成功，所以 <code>SYN</code> 也准确发送给了监听套接字。</p>
<p><code>Socket/ServerSocket</code> 的区别我们就讲到这里。如果读者觉得不过瘾，可以参考《TCP/IP 详解》卷1、卷2。</p>
<h2 id="Socket-长连接的实现"><a href="#Socket-长连接的实现" class="headerlink" title="Socket 长连接的实现"></a>Socket 长连接的实现</h2><p><strong>背景知识</strong></p>
<p>Socket 长连接，指的是在客户和服务端之间保持一个 socket 连接长时间不断开。</p>
<p>比较熟悉 <code>Socket</code> 的读者，可能知道有这样一个 API：</p>
<pre><code>socket.setKeepAlive(true);
</code></pre>
<p>嗯……keep alive，“保持活着”，这个应该就是让 TCP 不断开的意思。那么，我们要实现一个 socket 的长连接，只需要这一个调用即可。</p>
<p>遗憾的是，生活并不总是那么美好。对于 4.4BSD 的实现来说，Socket 的这个 keep alive 选项如果打开并且<strong>两个小时</strong>内没有通信，那么底层会发一个心跳，看看对方是不是还活着。</p>
<p>注意，两个小时才会发一次。也就是说，在没有实际数据通信的时候，我把网线拔了，你的应用程序要经过两个小时才会知道。</p>
<p>在说明如果实现长连接前，我们先来理一理我们面临的问题。假定现在有一对已经连接的 socket，在以下情况发生时候，socket 将不再可用：</p>
<ol>
<li>某一端关闭是 socket（这不是废话吗）。主动关闭的一方会发送 <code>FIN</code>，通知对方要关闭 TCP 连接。在这种情况下，另一端如果去读 socket，将会读到 <code>EoF</code>（End of File）。于是我们知道对方关闭了 socket。</li>
<li>应用程序奔溃。此时 socket 会由内核关闭，结果跟情况1一样。</li>
<li>系统奔溃。这时候系统是来不及发送 <code>FIN</code> 的，因为它已经跪了。此时对方无法得知这一情况。对方在尝试读取数据时，最后会返回 read time out。如果写数据，则是 host unreachable 之类的错误。</li>
<li>电缆被挖断、网线被拔。跟情况3差不多，如果没有对 socket 进行读写，两边都不知道发生了事故。跟情况3不同的是，如果我们把网线接回去，socket 依旧可以正常使用。</li>
</ol>
<p>在上面的几种情形中，有一个共同点就是，只要去读、写 socket，只要 socket 连接不正常，我们就能够知道。基于这一点，要实现一个 socket 长连接，我们需要做的就是不断地给对方写数据，然后读取对方的数据，也就是所谓的<strong>心跳</strong>。只要心还在跳，socket 就是活的。写数据的间隔，需要根据实际的应用需求来决定。</p>
<p>心跳包不是实际的业务数据，根据通信协议的不同，需要做不同的处理。</p>
<p>比方说，我们使用 JSON 进行通信，那么，我们可以加一个 <code>type</code> 字段，表面这个 JSON 是心跳还是业务数据。</p>
<pre><code>&#123;
    &quot;type&quot;: 0,  // 0 表示心跳

    // ...
&#125;
</code></pre>
<p>使用二进制协议的情况类似。要求就是，我们能够区别一个数据包是心跳还是真实数据。这样，我们便实现了一个 socket 长连接。</p>
<p><strong>实现示例</strong></p>
<p>这一小节我们一起来实现一个带长连接的 Android echo 客户端。</p>
<p>首先了接口部分：</p>
<pre><code>public final class LongLiveSocket &#123;

    /**
     * 错误回调
     */
    public interface ErrorCallback &#123;
        /**
         * 如果需要重连，返回 true
         */
        boolean onError();
    &#125;

    /**
     * 读数据回调
     */
    public interface DataCallback &#123;
        void onData(byte[] data, int offset, int len);
    &#125;

    /**
     * 写数据回调
     */
    public interface WritingCallback &#123;
        void onSuccess();
        void onFail(byte[] data, int offset, int len);
    &#125;


    public LongLiveSocket(String host, int port,
                          DataCallback dataCallback, ErrorCallback errorCallback) &#123;
    &#125;

    public void write(byte[] data, WritingCallback callback) &#123;
    &#125;

    public void write(byte[] data, int offset, int len, WritingCallback callback) &#123;
    &#125;

    public void close() &#123;
    &#125;
&#125;
</code></pre>
<p>我们这个支持长连接的类就叫 <code>LongLiveSocket</code> 好了。如果在 socket 断开后需要重连，只需要在对应的接口里面返回 true 即可（在真实场景里，我们还需要让客户设置重连的等待时间，还有读写、连接的 timeout等。为了简单，这里就直接不支持了。</p>
<p>另外需要注意的一点是，如果要做一个完整的库，需要同时提供阻塞式和回调式API。同样由于篇幅原因，这里直接省掉了。</p>
<p>首先我们看看 <code>write()</code> 方法：</p>
<pre><code>public void write(byte[] data, int offset, int len, WritingCallback callback) &#123;
    mWriterHandler.post(() -&gt; &#123;
        Socket socket = getSocket();
        if (socket == null) &#123;
            // initSocket 失败而客户说不需要重连，但客户又叫我们给他发送数据
            throw new IllegalStateException(&quot;Socket not initialized&quot;);
        &#125;
        try &#123;
            OutputStream outputStream = socket.getOutputStream();
            DataOutputStream out = new DataOutputStream(outputStream);
            out.writeInt(len);
            out.write(data, offset, len);
            callback.onSuccess();
        &#125; catch (IOException e) &#123;
            Log.e(TAG, &quot;write: &quot;, e);
            // 关闭 socket，避免资源泄露
            closeSocket();
            // 这里我们把发生失败的数据返回给客户端，这样客户可以更方便地重新发送数据
            callback.onFail(data, offset, len);
            if (!closed() &amp;&amp; mErrorCallback.onError()) &#123;
                // 重连
                initSocket();
            &#125;
        &#125;
    &#125;);
&#125;
</code></pre>
<p>由于我们需要定时写心跳，这里使用一个 <code>HandlerThread</code> 来处理写请求。通信使用的协议，只是简单地在用户数据前加一个 len 字段，用于确定消息的长度。</p>
<p>下面我们看心跳的发送：</p>
<pre><code>private final Runnable mHeartBeatTask = new Runnable() &#123;
    private byte[] mHeartBeat = new byte[0];

    @Override
    public void run() &#123;
        // 我们使用长度为 0 的数据作为 heart beat
        write(mHeartBeat, new WritingCallback() &#123;
            @Override
            public void onSuccess() &#123;
                // 每隔 HEART_BEAT_INTERVAL_MILLIS 发送一次
                mWriterHandler.postDelayed(mHeartBeatTask, HEART_BEAT_INTERVAL_MILLIS);
                mUIHandler.postDelayed(mHeartBeatTimeoutTask, HEART_BEAT_TIMEOUT_MILLIS);
            &#125;

            @Override
            public void onFail(byte[] data, int offset, int len) &#123;
                // nop
                // write() 方法会处理失败
            &#125;
        &#125;);
    &#125;
&#125;;

private final Runnable mHeartBeatTimeoutTask = () -&gt; &#123;
    Log.e(TAG, &quot;mHeartBeatTimeoutTask#run: heart beat timeout&quot;);
    closeSocket();
&#125;;
</code></pre>
<p>发送心跳使用我们上面实现的 <code>write()</code> 方法。在发送成功后，post delay 一个 timeout task，如果到期后还没收到服务器的响应，我们将认为 socket 出现异常，这里直接关闭 socket。最后是对心跳的处理：</p>
<pre><code>int nbyte = in.readInt();
if (nbyte == 0) &#123;
    Log.i(TAG, &quot;readResponse: heart beat received&quot;);
    mUIHandler.removeCallbacks(mHeartBeatTimeoutTask);
&#125;
</code></pre>
<p>由于用户数据的长度总是会大于 1，这里我们就使用 <code>len == 0</code> 的数据作为心跳。收到心跳后，移除 <code>mHeartBeatTimeoutTask</code>。</p>
<p>剩余代码跟我们的主题没有太大关系，读者在<a target="_blank" rel="noopener" href="https://github.com/Jekton/Echo">这里</a>[3]可以找到完整的代码或者自己完成这个例子。</p>
<p>最后需要说明的是，如果想节省资源，在有客户发送数据的时候可以省略 heart beat。</p>
<p>我们对读出错时候的处理，可能也存在一些争议。读出错后，我们只是关闭了 socket。socket 需要等到下一次写动作发生时，才会重新连接。实际应用中，如果这是一个问题，在读出错后可以直接开始重连。这种情况下，还需要一些额外的同步，避免重复创建 socket。heart beat timeout 的情况类似。</p>
<h2 id="跟-TCP-IP-学协议设计"><a href="#跟-TCP-IP-学协议设计" class="headerlink" title="跟 TCP/IP 学协议设计"></a>跟 TCP/IP 学协议设计</h2><p>如果仅仅是为了使用是 socket，我们大可以不去理会协议的细节。之所以推荐大家去看一看《TCP/IP 详解》，是因为它们有太多值得学习的地方。很多我们工作中遇到的问题，都可以在这里找到答案。</p>
<p>以下每一个小节的标题都是一个小问题，建议读者独立思考一下，再继续往下看。如果你发现你的答案比我的更好，请一定发送邮件到 ljtong64 AT gmail DOT com 告诉我。</p>
<h4 id="协议版本如何升级？"><a href="#协议版本如何升级？" class="headerlink" title="协议版本如何升级？"></a>协议版本如何升级？</h4><p>有这么一句流行的话：这个世界唯一不变的，就是变化。当我们对协议版本进行升级的时候，正确识别不同版本的协议对软件的兼容非常重要。那么，我们如何设计协议，才能够为将来的版本升级做准备呢？</p>
<p>答案可以在 IP 协议找到。</p>
<p>IP 协议的第一个字段叫 version，目前使用的是 4 或 6，分别表示 IPv4 和 IPv6。由于这个字段在协议的开头，接收端收到数据后，只要根据第一个字段的值就能够判断这个数据包是 IPv4 还是 IPv6。</p>
<p>再强调一下，这个字段在两个版本的IP协议都位于第一个字段，为了做兼容处理，对应的这个字段必须位于同一位置。文本协议（如，JSON、HTML）的情况类似。</p>
<h4 id="如何发送不定长数据的数据包"><a href="#如何发送不定长数据的数据包" class="headerlink" title="如何发送不定长数据的数据包"></a>如何发送不定长数据的数据包</h4><p>举个例子，我们用微信发送一条消息。这条消息的长度是不确定的，并且每条消息都有它的边界。我们如何来处理这个边界呢？</p>
<p>还是一样，看看 IP。IP 的头部有个 header length 和 data length 两个字段。通过添加一个 len 域，我们就能够把数据根据应用逻辑分开。</p>
<p>跟这个相对的，还有另一个方案，那就是在数据的末尾放置终止符。比方说，想 C 语言的字符串那样，我们在每个数据的末尾放一个 <code>\0</code> 作为终止符，用以标识一条消息的尾部。这个方法带来的问题是，用户的数据也可能存在 <code>\0</code>。此时，我们就需要对用户的数据进行<strong>转义</strong>。比方说，把用户数据的所有 <code>\0</code> 都变成 <code>\0\0</code>。读消息的过程总，如果遇到 <code>\0\0</code>，那它就代表 <code>\0</code>，如果只有一个 <code>\0</code>，那就是消息尾部。</p>
<p>使用 len 字段的好处是，我们不需要对数据进行转义。读取数据的时候，只要根据 len 字段，一次性把数据都读进来就好，效率会更高一些。</p>
<p>终止符的方案虽然要求我们对数据进行扫描，但是如果我们可能从任意地方开始读取数据，就需要这个终止符来确定哪里才是消息的开头了。</p>
<p>当然，这两个方法不是互斥的，可以一起使用。</p>
<h4 id="上传多个文件，只有所有文件都上传成功时才算成功"><a href="#上传多个文件，只有所有文件都上传成功时才算成功" class="headerlink" title="上传多个文件，只有所有文件都上传成功时才算成功"></a>上传多个文件，只有所有文件都上传成功时才算成功</h4><p>现在我们有一个需求，需要一次上传多个文件到服务器，只有在所有文件都上传成功的情况下，才算成功。我们该如何来实现呢？</p>
<p>IP 在数据报过大的时候，会把一个数据报拆分成多个，并设置一个 MF （more fragments）位，表示这个包只是被拆分后的数据的一部分。</p>
<p>好，我们也学一学 IP。这里，我们可以给每个文件从 0 开始编号。上传文件的同时，也携带这个编号，并额外附带一个 MF 标志。除了编号最大的文件，所有文件的 MF 标志都置位。因为 MF 没有置位的是最后一个文件，服务器就可以根据这个得出总共有多少个文件。</p>
<p>另一种不使用 MF 标志的方法是，我们在上传文件前，就告诉服务器总共有多少个文件。</p>
<p>如果读者对数据库比较熟悉，学数据库用事务来处理，也是可以的。这里就不展开讨论了。</p>
<h4 id="如何保证数据的有序性"><a href="#如何保证数据的有序性" class="headerlink" title="如何保证数据的有序性"></a>如何保证数据的有序性</h4><p>这里讲一个我曾经遇到过的面试题。现在有一个任务队列，多个工作线程从中取出任务并执行，执行结果放到一个结果队列中。先要求，放入结果队列的时候，顺序顺序需要跟从工作队列取出时的一样（也就是说，先取出的任务，执行结果需要先放入结果队列）。</p>
<p>我们看看 TCP/IP 是怎么处理的。IP 在发送数据的时候，不同数据报到达对端的时间是不确定的，后面发送的数据有可能较先到达。TCP 为了解决这个问题，给所发送数据的每个字节都赋了一个序列号，通过这个序列号，TCP 就能够把数据按原顺序重新组装。</p>
<p>一样，我们也给每个任务赋一个值，根据进入工作队列的顺序依次递增。工作线程完成任务后，在将结果放入结果队列前，先检查要放入对象的写一个序列号是不是跟自己的任务相同，如果不同，这个结果就不能放进去。此时，最简单的做法是等待，知道下一个可以放入队列的结果是自己所执行的那一个。但是，这个线程就没办法继续处理任务了。</p>
<p>更好的方法是，我们维护多一个结果队列的缓冲，这个缓冲里面的数据按序列号从小到大排序。工作线程要将结果放入，有两种可能：</p>
<ol>
<li>刚刚完成的任务刚好是下一个，将这个结果放入队列。然后从缓冲的头部开始，将所有可以放入结果队列的数据都放进去。</li>
<li>所完成的任务不能放入结果队列，这个时候就插入结果队列。然后，跟上一种情况一样，需要检查缓冲。</li>
</ol>
<p>如果测试表明，这个结果缓冲的数据不多，那么使用普通的链表就可以。如果数据比较多，可以使用一个最小堆。</p>
<h4 id="如何保证对方收到了消息"><a href="#如何保证对方收到了消息" class="headerlink" title="如何保证对方收到了消息"></a>如何保证对方收到了消息</h4><p>我们说，TCP 提供了可靠的传输。这样不就能够保证对方收到消息了吗？</p>
<p>很遗憾，其实不能。在我们往 socket 写入的数据，只要对端的内核收到后，就会返回 <code>ACK</code>，此时，socket 就认为数据已经写入成功。然而要注意的是，这里只是对方所运行的系统的内核成功收到了数据，并不表示应用程序已经成功处理了数据。</p>
<p>解决办法还是一样，我们学 <code>TCP</code>，添加一个应用层的 <code>APP ACK</code>。应用接收到消息并处理成功后，发送一个 <code>APP ACK</code> 给对方。</p>
<p>有了 <code>APP ACK</code>，我们需要处理的另一个问题是，如果对方真的没有收到，需要怎么做？</p>
<p>TCP 发送数据的时候，消息一样可能丢失。TCP 发送数据后，如果长时间没有收到对方的 <code>ACK</code>，就假设数据已经丢失，并重新发送。</p>
<p>我们也一样，如果长时间没有收到 <code>APP ACK</code>，就假设数据丢失，重新发送一个。</p>

        </div>

    </div>

    

    

    

    

    

    
<nav class="article-nav">
  
    <a href="/2020/06/12/%E5%8A%A8%E6%80%81%E4%BB%A3%E7%90%86%E8%AE%BE%E8%AE%A1%E6%A8%A1%E5%BC%8F/" id="article-nav-newer" class="article-nav-link-wrap">
      <div class="article-nav-caption">下一篇</div>
      <div class="article-nav-title">
        
          动态代理设计模式
        
      </div>
    </a>
  
  
    <a href="/2020/06/12/Android%E7%9B%AE%E5%89%8D%E6%9C%80%E7%A8%B3%E5%AE%9A%E5%92%8C%E9%AB%98%E6%95%88%E7%9A%84UI%E9%80%82%E9%85%8D%E6%96%B9%E6%A1%88/" id="article-nav-older" class="article-nav-link-wrap">
      <div class="article-nav-caption">上一篇</div>
      <div class="article-nav-title">Android目前最稳定和高效的UI适配方案</div>
    </a>
  
</nav>


    <section class="share">
        <div class="share-title">分享</div>
        <a class="share-item" target="_blank"
            href="https://twitter.com/share?text=手把手教你写Socket长连接 - ApocalypseBlog&url=http://example.com/2020/06/12/%E6%89%8B%E6%8A%8A%E6%89%8B%E6%95%99%E4%BD%A0%E5%86%99Socket%E9%95%BF%E8%BF%9E%E6%8E%A5/">
            <box-icon type='logo' name='twitter'></box-icon>
        </a>
        <a class="share-item" target="_blank"
            href="https://www.facebook.com/sharer.php?title=手把手教你写Socket长连接 - ApocalypseBlog&u=http://example.com/2020/06/12/%E6%89%8B%E6%8A%8A%E6%89%8B%E6%95%99%E4%BD%A0%E5%86%99Socket%E9%95%BF%E8%BF%9E%E6%8E%A5/">
            <box-icon name='facebook-square' type='logo' ></box-icon>
        </a>
        <!-- <a class="share-item" target="_blank"
            href="https://service.weibo.com/share/share.php?title=手把手教你写Socket长连接 - ApocalypseBlog&url=http://example.com/2020/06/12/%E6%89%8B%E6%8A%8A%E6%89%8B%E6%95%99%E4%BD%A0%E5%86%99Socket%E9%95%BF%E8%BF%9E%E6%8E%A5/&pic=">
            <div class="n-icon n-icon-weibo"></div>
        </a> -->
    </section>

</article>









</div>
                </section>
            </section>

             
            <aside class="sidebar">
            
                
            </aside>
        </div>
    </div>

    <footer class="footer">
    <div class="footer-wave">
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1440 320"><path fill="#3c4859" fill-opacity="1" d="M0,160L60,181.3C120,203,240,245,360,240C480,235,600,181,720,186.7C840,192,960,256,1080,261.3C1200,267,1320,213,1380,186.7L1440,160L1440,320L1380,320C1320,320,1200,320,1080,320C960,320,840,320,720,320C600,320,480,320,360,320C240,320,120,320,60,320L0,320Z"></path></svg>
    </div>

    <div class="footer-wrap">
        <div class="footer-inner"> 
            ApocalypseBlog &copy; 2021<br>
            Powered By Hexo · Theme By <a href="https://github.com/lh1me/hexo-theme-aomori" target="_blank">Aomori</a>
        </div>
    </div>

</footer>




<script src="/dist/build.js?1.14.0.js"></script>


<script src="/dist/custom.js?1.14.0.js"></script>









</body>

</html>